This sketch recreates the classic Crossy Road game: a red square hops between lanes of grass, traffic, and water, riding logs and dodging cars while the camera scrolls upward to follow progress. It tracks a score based on distance traveled and persists a high score across sessions using the browser's local storage.
This sketch builds a full Crossy Road style game where a player square hops forward through procedurally generated lanes of grass, road, and water, dodging cars and riding logs to avoid falling in. What makes it interesting is how much it accomplishes with simple rectangles: a scrolling world illusion, endless lane generation, moving obstacles in both directions, and win/lose logic, all built from p5.js primitives like rect(), constrain(), and random(). It leans on object-oriented JavaScript (ES6 classes) to organize the Player, Obstacle, and Lane entities, and on a finite state machine (START, PLAYING, GAME_OVER) to control what's drawn each frame.
The code is organized into three classes - Player, Obstacle, and Lane - each responsible for drawing and updating itself, plus a set of p5.js lifecycle functions (setup, draw, keyPressed) that tie everything together. By studying it you'll learn how to fake camera scrolling by offsetting draw positions instead of moving the whole world, how to detect axis-aligned bounding box collisions, how to recycle and regenerate off-screen objects for infinite procedural content, and how to persist data between sessions with getItem/storeItem.
⚙️ How It Works
When the sketch loads, setup() creates a full-window canvas, loads any saved high score from local storage, and calls resetGame() to build the player and the first batch of lanes, leaving the game in a 'START' state showing a title screen.
Every frame, draw() clears the background and calls a different function depending on gameState - drawStartScreen(), playGame(), or drawGameOverScreen() - so only one screen is ever active at a time.
During PLAYING, playGame() first computes scrollOffset from the difference between the player's fixed canvas position and their real progress in the game world, then draws every lane and obstacle shifted by that offset - this is what creates the illusion that the world scrolls down as the player moves up.
Each Lane updates its own Obstacle objects (cars or logs), removing ones that scroll off-screen and spawning new ones at the leading edge so the traffic feels endless.
Pressing an arrow key calls player.move(), which shifts the player's x/y and updates score; checkSafety() then tests the player's bounding box against the current lane's obstacles - colliding with a car or falling in water without a log sets gameState to GAME_OVER.
On GAME_OVER, drawGameOverScreen() compares the current score to the stored high score, saves a new record if beaten, and waits for any key press to call resetGame() again and restart the loop.
🎓 Concepts You'll Learn
ES6 classes for game objectsFinite state machines (START/PLAYING/GAME_OVER)Camera scrolling via draw-position offsetAxis-aligned bounding box (AABB) collision detectionProcedural, infinite obstacle generationArray filtering to recycle off-screen objectsPersistent data with getItem/storeItem (local storage)
📝 Code Breakdown
Player constructor()
This is the class constructor that runs once when `new Player(...)` is called in resetGame(). It sets up the player's starting state before any drawing or movement happens.
constructor(x, y) {
this.x = x;
this.y = y; // Fixed Y position on the canvas
this.size = PLAYER_SIZE;
this.safe = true;
this.isOnLog = false;
this.currentLane = null;
}
Line-by-line explanation (6 lines)
this.x = x;
Stores the player's horizontal canvas position.
this.y = y; // Fixed Y position on the canvas
The player's vertical position on screen never actually changes - only the world scrolls around it.
this.size = PLAYER_SIZE;
Sets the width/height of the player's square from the global constant.
this.safe = true;
Tracks whether the player is currently alive/safe; used to trigger game over.
this.isOnLog = false;
Flags whether the player is currently riding a log in a water lane.
this.currentLane = null;
Keeps a reference to the lane the player is standing in, set later by checkSafety().
Player.draw()
Every game object in this sketch has its own draw() method, keeping rendering logic bundled with the object it belongs to - a common OOP pattern in games.
draw() {
fill(255, 0, 0); // Red player
rectMode(CENTER);
rect(this.x, this.y, this.size, this.size);
}
Line-by-line explanation (3 lines)
fill(255, 0, 0); // Red player
Sets the fill color to bright red for the player square.
rectMode(CENTER);
Makes rect() draw from the shape's center instead of its top-left corner, so this.x/this.y is the middle of the square.
rect(this.x, this.y, this.size, this.size);
Draws the player as a square using its fixed canvas position and size.
Player.move()
move() is called from keyPressed() whenever an arrow key is hit. Separating 'canvas position' (this.y, fixed) from 'world position' (playerActualY, ever-changing) is the trick that makes the scrolling illusion work.
move(dx, dy) {
// dx is step size (e.g., LANE_HEIGHT), dy is laneHeight
this.x = constrain(this.x + dx, this.size / 2, width - this.size / 2);
playerActualY -= dy; // Player's global Y position decreases when moving up
score = max(score, abs(playerActualY - playerCanvasY)); // Score is distance from initial Y
}
Moves the player horizontally by dx, but constrain() clamps the result so the player can never leave the canvas edges.
playerActualY -= dy;
Updates the player's true world position - moving up (positive dy) decreases playerActualY, which is how progress is tracked separately from the fixed on-screen y.
The score is the farthest distance ever traveled from the starting point - max() means score only ever increases, even if the player later steps backward.
Player.updateOnLog()
This method is called from inside checkSafety() every frame the player is in a water lane, simulating being physically carried by a moving log.
Keeps the player from being carried off the edge of the canvas by a fast log.
Player.checkSafety()
checkSafety() is the game's core rules engine - it runs every frame in playGame() and is what turns simple rectangle math into life-or-death gameplay decisions.
🔬 This is a classic AABB (axis-aligned bounding box) overlap test using four comparisons. What happens if you shrink the player's hitbox by replacing `this.size / 2` with `this.size / 4` in all four lines - does the game feel more forgiving?
checkSafety(currentLane) {
this.safe = true;
this.isOnLog = false;
this.currentLane = currentLane;
if (currentLane.type === 'road') {
for (let obs of currentLane.obstacles) {
// Check collision using player's fixed canvas Y and obstacle's scrolled Y
if (this.x - this.size / 2 < obs.x + obs.w &&
this.x + this.size / 2 > obs.x &&
this.y - this.size / 2 < obs.y + obs.h + scrollOffset &&
this.y + this.size / 2 > obs.y + scrollOffset) {
this.safe = false; // Collided with car
break;
}
}
} else if (currentLane.type === 'water') {
let onALog = false;
for (let obs of currentLane.obstacles) {
// Check if player's X range overlaps with the log's X range
if (this.x - this.size / 2 < obs.x + obs.w &&
this.x + this.size / 2 > obs.x) {
onALog = true;
this.isOnLog = true;
this.updateOnLog(obs.speed); // Move player with log
break;
}
}
if (!onALog) {
this.safe = false; // Drowned
}
}
// Grass lanes are always safe
}
Line-by-line explanation (6 lines)
🔧 Subcomponents:
for-loopCar Collision Checkfor (let obs of currentLane.obstacles) {
Tests the player's bounding box against every car in the lane to detect a collision
for-loopLog Overlap Checkfor (let obs of currentLane.obstacles) {
Checks whether the player's x-range overlaps any log, meaning they are safely floating
this.safe = true;
Assumes the player is safe by default, then this function tries to prove otherwise.
if (currentLane.type === 'road') {
Road lanes use full rectangle overlap (x AND y) since cars can be anywhere vertically within the lane.
this.safe = false; // Collided with car
If any car's box overlaps the player's box, mark the player unsafe and stop checking further obstacles.
} else if (currentLane.type === 'water') {
Water lanes only check horizontal (x) overlap, because the player's vertical position on screen is fixed - what matters is whether they're standing on any log.
this.updateOnLog(obs.speed); // Move player with log
If standing on a log, immediately shifts the player sideways with it this frame.
if (!onALog) { this.safe = false; // Drowned }
If no log was found under the player in a water lane, they've fallen in and the game ends.
Obstacle constructor()
Obstacle is a small, reusable data class for anything that moves horizontally across a lane - both cars and logs use the exact same class.
constructor(x, y, w, h, speed, type) {
this.x = x;
this.y = y; // Y position relative to scrollOffset 0
this.w = w;
this.h = h;
this.speed = speed;
this.type = type;
}
Line-by-line explanation (3 lines)
this.y = y; // Y position relative to scrollOffset 0
Obstacles store an unscrolled y position; the current scrollOffset is only added at draw time, not stored.
this.speed = speed;
How many pixels this obstacle moves per frame - positive moves right, negative moves left.
this.type = type;
Either 'car' or 'log', which determines its color and collision behavior.
Obstacle.draw()
Notice draw() never modifies this.y directly - it only offsets the position for rendering, keeping the obstacle's true world position untouched for collision math.
draw(scrollOffset) {
if (this.type === 'car') {
fill(0, 0, 200); // Blue car
rectMode(CORNER);
rect(this.x, this.y + scrollOffset, this.w, this.h);
} else if (this.type === 'log') {
fill(139, 69, 19); // Brown log
rectMode(CORNER);
rect(this.x, this.y + scrollOffset, this.w, this.h);
}
}
Checks the right edge for rightward movers and the left edge for leftward movers
if (this.speed > 0) { return this.x > width; }
For obstacles moving right, they're off-screen once their left edge passes the right side of the canvas.
else { return this.x + this.w < 0; }
For obstacles moving left, they're off-screen once their right edge passes the left side of the canvas.
Lane constructor()
Lane is the biggest class in the sketch - it owns a strip of the game world, its background color, and every obstacle inside it.
constructor(y, type, speed, direction) {
this.y = y; // Top Y position relative to scrollOffset 0
this.type = type;
this.speed = speed * direction;
this.direction = direction;
this.obstacles = [];
this.generateObstacles();
}
Line-by-line explanation (2 lines)
this.speed = speed * direction;
Combines a base speed with a direction (1 or -1) so lanes can send obstacles either left or right.
this.generateObstacles();
Immediately fills this lane with an initial set of cars/logs when it's created.
Lane.generateObstacles()
This method runs once when a Lane is first created, seeding it with a believable spread of traffic or logs before the game even starts drawing it.
🔬 OBSTACLE_DENSITY is compared against a fresh random() roll for every slot. What happens visually if you hard-code this to `if (random() < 0.9)` - do lanes become nearly solid walls of traffic?
if (random() < OBSTACLE_DENSITY) {
generateObstacles() {
this.obstacles = [];
let x = random(-width, 0); // Start some obstacles off-screen to the left
while (x < width * 2) { // Generate obstacles across two screen widths
if (random() < OBSTACLE_DENSITY) {
let obsWidth;
if (this.type === 'road') {
obsWidth = random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 3); // Car width
} else if (this.type === 'water') {
obsWidth = random(LANE_HEIGHT * 2, LANE_HEIGHT * 4); // Log width
}
let obsHeight = LANE_HEIGHT * 0.7; // Obstacle height
this.obstacles.push(new Obstacle(x, this.y + LANE_HEIGHT * 0.15, obsWidth, obsHeight, this.speed, this.type));
x += obsWidth + random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 4); // Gap between obstacles
} else {
x += LANE_HEIGHT * random(1, 3); // Gap if no obstacle
}
}
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
while-loopFill Lane With Obstacleswhile (x < width * 2) {
Walks a virtual cursor across two screen-widths of space, randomly placing obstacles with gaps between them
conditionalObstacle Density Rollif (random() < OBSTACLE_DENSITY) {
Randomly decides whether this slot gets an obstacle or stays empty
let x = random(-width, 0);
Starting cursor position begins somewhere off-screen to the left so obstacles don't all pop in visibly at once.
while (x < width * 2) {
Keeps placing obstacles/gaps until the cursor has walked across two full screen widths, giving a buffer of content.
if (random() < OBSTACLE_DENSITY) {
Rolls a random number 0-1 and compares it to the density constant - lower OBSTACLE_DENSITY means fewer obstacles spawn.
x += obsWidth + random(LANE_HEIGHT * 1.5, LANE_HEIGHT * 4); // Gap between obstacles
Advances the cursor past the obstacle just placed, plus a random gap, before considering the next slot.
Lane.draw()
Lane.draw() shows the 'draw at scrolled position, keep true position unchanged' pattern used everywhere in this sketch to fake camera movement without ever actually moving the canvas.
🔬 This loop spaces road markings by LANE_HEIGHT * 1.5. What happens if you shrink that spacing to LANE_HEIGHT * 0.5 - do the roads look busier or more like a highway?
for (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {
fill(255);
rect(i, this.y + scrollOffset + LANE_HEIGHT / 2 - 5, LANE_HEIGHT * 0.75, 10);
}
draw(scrollOffset) {
noStroke();
if (this.type === 'grass') {
fill(0, 200, 0); // Green grass
} else if (this.type === 'road') {
fill(50); // Dark grey road
// Draw road markings
for (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {
fill(255);
rect(i, this.y + scrollOffset + LANE_HEIGHT / 2 - 5, LANE_HEIGHT * 0.75, 10);
}
} else if (this.type === 'water') {
fill(0, 0, 150); // Blue water
}
rectMode(CORNER);
rect(0, this.y + scrollOffset, width, LANE_HEIGHT);
for (let obs of this.obstacles) {
obs.draw(scrollOffset);
}
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
for-loopRoad Marking Stripesfor (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {
Draws evenly-spaced white dash marks across a road lane to make it read as a street
for-loopDraw Each Obstaclefor (let obs of this.obstacles) {
Draws every car or log currently in this lane
if (this.type === 'grass') { fill(0, 200, 0); }
Picks the lane's base background color depending on its type.
for (let i = 0; i < width; i += LANE_HEIGHT * 1.5) {
Steps across the full canvas width placing a white dash roughly every 1.5 lane-heights, mimicking road markings.
Draws the full-width background strip for this lane, shifted down/up by scrollOffset so it lines up with the scrolling world.
for (let obs of this.obstacles) { obs.draw(scrollOffset); }
Draws every obstacle in this lane, passing along the same scrollOffset so everything moves together.
Lane.update()
Lane.update() demonstrates the 'object pooling by recycling' pattern common in games: rather than tracking a giant infinite world, only nearby obstacles exist, and they're continuously deleted and respawned to simulate endlessness.
🔬 This single line is what makes the traffic endless. What do you think happens if you comment this line out entirely - will the obstacle array keep growing forever?
Places a new obstacle just behind the current leading one (with a random gap) so traffic looks continuous rather than randomly popping in.
setup()
setup() runs exactly once when the sketch starts, and is the natural place to size the canvas and initialize starting state.
function setup() {
createCanvas(windowWidth, windowHeight);
highestScore = getItem('crossyRoadHighestScore') || 0; // Load highest score
resetGame();
userStartAudio(); // Required for audio on mobile, even if not using sound yet
}
Line-by-line explanation (4 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window.
Reads a saved high score from the browser's local storage via p5's getItem(); if nothing was saved yet, defaults to 0.
resetGame();
Builds the player and the initial set of lanes so the game is ready to play (or show its start screen).
userStartAudio();
Unlocks audio playback on mobile browsers, which normally block sound until a user gesture - a defensive line even though this sketch doesn't currently play sounds.
draw()
draw() runs continuously at the sketch's frame rate. Here it's kept intentionally tiny, acting only as a dispatcher to the right state-specific function - a clean way to structure a multi-screen game.
function draw() {
background(220);
if (gameState === 'START') {
drawStartScreen();
} else if (gameState === 'PLAYING') {
playGame();
} else if (gameState === 'GAME_OVER') {
drawGameOverScreen();
}
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
conditionalGame State Dispatchif (gameState === 'START') {
Chooses which screen-drawing function to call based on the current gameState
background(220);
Clears the whole canvas to light grey every frame before anything else is drawn.
if (gameState === 'START') { drawStartScreen(); }
Shows the title screen while gameState is 'START'.
} else if (gameState === 'PLAYING') { playGame(); }
Runs the entire game loop - lane updates, drawing, collision checks - while actively playing.
} else if (gameState === 'GAME_OVER') { drawGameOverScreen(); }
Freezes gameplay and shows the game over overlay once the player has died.
drawStartScreen()
This function is purely presentational - it just draws UI text and reads no input itself; input handling for starting the game lives in keyPressed()/touchStarted().
Displays the persisted best score below the instructions, using string concatenation to combine text and a number.
playGame()
playGame() is the game's central loop, run every frame while playing - it ties together scrolling, lane recycling, drawing, and collision checks in one place.
🔬 This loop deletes lanes once they scroll past the bottom of the screen. What happens if you change `> height` to `> height + 500` so lanes stick around 500px longer after leaving view?
for (let i = lanes.length - 1; i >= 0; i--) {
let lane = lanes[i];
lane.update();
lane.draw(scrollOffset);
// Remove lanes that are off-screen at the bottom
if (lane.y + LANE_HEIGHT + scrollOffset > height) {
lanes.splice(i, 1);
}
}
function playGame() {
// Update scrollOffset as player moves up
scrollOffset = playerCanvasY - playerActualY;
// Draw and update lanes
for (let i = lanes.length - 1; i >= 0; i--) {
let lane = lanes[i];
lane.update();
lane.draw(scrollOffset);
// Remove lanes that are off-screen at the bottom
if (lane.y + LANE_HEIGHT + scrollOffset > height) {
lanes.splice(i, 1);
}
}
// Add new lanes at the top as they are removed from bottom
while (lanes.length < NUM_PREGEN_LANES) {
let lastLane = lanes[lanes.length - 1];
let newLaneY = lastLane ? lastLane.y - LANE_HEIGHT : -LANE_HEIGHT; // Position above the last lane, or at -LANE_HEIGHT if first lane
lanes.push(generateNewLane(newLaneY));
}
// Draw player
player.draw();
// Find the lane the player is currently in (based on playerActualY)
let playerLane = null;
for (let lane of lanes) {
if (playerActualY >= lane.y && playerActualY < lane.y + LANE_HEIGHT) {
playerLane = lane;
break;
}
}
if (playerLane) {
player.checkSafety(playerLane);
if (!player.safe) {
gameState = 'GAME_OVER';
}
} else {
// If player somehow moved off the top of all lanes (shouldn't happen with current logic)
gameState = 'GAME_OVER';
}
// Check if player moved off canvas left/right
if (player.x < PLAYER_SIZE / 2 || player.x > width - PLAYER_SIZE / 2) {
gameState = 'GAME_OVER';
}
// Check if player moved off the bottom of the starting area (game over)
// This prevents moving infinitely down
if (playerActualY > playerCanvasY + LANE_HEIGHT) {
gameState = 'GAME_OVER';
}
// Display score
fill(0);
textAlign(LEFT, TOP);
textSize(24);
text("Score: " + score, 10, 10);
text("Highest: " + highestScore, 10, 40);
}
Line-by-line explanation (6 lines)
🔧 Subcomponents:
for-loopUpdate & Draw All Lanesfor (let i = lanes.length - 1; i >= 0; i--) {
Iterates lanes backwards so splicing out off-screen lanes doesn't skip elements
while-loopKeep Lane Count Topped Upwhile (lanes.length < NUM_PREGEN_LANES) {
Adds fresh lanes above the highest existing lane whenever old ones get removed
for-loopLocate Player's Current Lanefor (let lane of lanes) {
Searches the lanes array for the one whose y-range contains the player's current world position
scrollOffset = playerCanvasY - playerActualY;
The heart of the scrolling illusion: as playerActualY decreases (player moves up), scrollOffset grows, pushing everything drawn further down the screen relative to its stored position.
for (let i = lanes.length - 1; i >= 0; i--) {
Loops backward through the lanes array - important because lane.splice(i, 1) inside the loop removes an item, and looping backward avoids skipping the next element.
A safety check - though move() already constrains x, this double-checks the player hasn't ended up off-canvas.
drawGameOverScreen()
This function runs every single frame while in GAME_OVER state, which is why updateHighestScore() includes its own internal check to avoid re-saving unnecessarily.
updateHighestScore(); // Update highest score before displaying game over screen
Checks and possibly saves a new personal best before the screen is drawn, so the displayed number is always current.
fill(0, 150);
Sets a semi-transparent black fill (alpha 150 out of 255) so the game scene is still faintly visible behind the overlay.
rect(0, 0, width, height);
Draws a full-screen dark overlay rectangle to dim the frozen game behind the game-over text.
keyPressed()
keyPressed() is a built-in p5.js callback that fires once per key press, letting you map keyCode values to game actions.
function keyPressed() {
if (gameState === 'START' || gameState === 'GAME_OVER') {
gameState = 'PLAYING';
if (gameState === 'GAME_OVER') {
resetGame();
}
return false;
}
if (gameState === 'PLAYING') {
switch (keyCode) {
case UP_ARROW:
player.move(0, LANE_HEIGHT);
break;
case DOWN_ARROW:
player.move(0, -LANE_HEIGHT);
// Prevent player from moving below initial starting position
playerActualY = min(playerActualY, playerCanvasY);
break;
case LEFT_ARROW:
player.move(-LANE_HEIGHT, 0); // Move left by one laneHeight step (for simplicity, could be smaller)
break;
case RIGHT_ARROW:
player.move(LANE_HEIGHT, 0); // Move right by one laneHeight step
break;
}
return false; // Prevent default browser behavior
}
}
Any key press from the start or game-over screens begins (or restarts) the game
switch-caseArrow Key Movementswitch (keyCode) {
Maps each arrow key to a specific move() call on the player
if (gameState === 'START' || gameState === 'GAME_OVER') {
Any key press works to leave the start screen or the game-over screen.
gameState = 'PLAYING';
Switches straight into gameplay mode.
if (gameState === 'GAME_OVER') { resetGame(); }
Intended to reset the game when restarting after a loss - but since gameState was just set to 'PLAYING' on the line above, this condition is always false (see improvements).
case UP_ARROW: player.move(0, LANE_HEIGHT); break;
Pressing up moves the player forward by one lane height.
Clamps the player's world y so moving down can never push them below their original starting row.
return false;
Prevents the browser's default behavior for arrow keys (like scrolling the page).
touchStarted()
touchStarted() is p5.js's built-in mobile touch callback, mirroring keyPressed() so the game is playable without a keyboard.
function touchStarted() {
if (gameState === 'START' || gameState === 'GAME_OVER') {
gameState = 'PLAYING';
if (gameState === 'GAME_OVER') {
resetGame();
}
return false;
}
if (gameState === 'PLAYING') {
// Basic touch input: tap anywhere to move up
// For more advanced touch, would need to detect tap direction or area
player.move(0, LANE_HEIGHT);
return false; // Prevent default browser behavior (scrolling)
}
}
Line-by-line explanation (2 lines)
player.move(0, LANE_HEIGHT);
On mobile, any tap simply moves the player forward one lane - a simplified control scheme compared to the four-directional keyboard input.
Stops the touch event from also scrolling or zooming the page on mobile browsers.
generateNewLane()
This factory function is called whenever a brand-new lane is needed - both during initial setup and continuously during play as old lanes scroll away.
🔬 Lane type is chosen with equal odds from these three strings. What happens if you duplicate 'road' in the array so it's picked twice as often - does the game feel more dangerous?
let laneType = random(['grass', 'road', 'water']);
function generateNewLane(y) {
let laneType = random(['grass', 'road', 'water']);
let direction = random() < 0.5 ? 1 : -1; // 1 for right, -1 for left
let speed;
if (laneType === 'road') {
speed = CAR_SPEED_FACTOR * random(0.8, 1.2);
} else if (laneType === 'water') {
speed = LOG_SPEED_FACTOR * random(0.8, 1.2);
} else {
speed = 0; // Grass lanes have no moving obstacles
}
return new Lane(y, laneType, speed, direction);
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
conditionalSpeed By Lane Typeif (laneType === 'road') {
Assigns a base speed depending on whether the new lane is road, water, or grass
let laneType = random(['grass', 'road', 'water']);
Picks one of three lane types completely at random using p5's random() with an array argument.
let direction = random() < 0.5 ? 1 : -1;
A coin-flip decides whether this lane's obstacles travel right (1) or left (-1).
speed = CAR_SPEED_FACTOR * random(0.8, 1.2);
Adds slight per-lane speed variation (80%-120% of the base) so not every road lane feels identical.
return new Lane(y, laneType, speed, direction);
Builds and returns the actual Lane object using the randomly chosen parameters.
resetGame()
resetGame() is called both at the very first launch and every time a new game begins after game over - it rebuilds all state from scratch, which is why fixing the restart bug (see improvements) matters.
function resetGame() {
score = 0;
highestScore = getItem('crossyRoadHighestScore') || 0; // Load highest score
playerCanvasY = height - LANE_HEIGHT * PLAYER_START_LANE_INDEX - LANE_HEIGHT / 2;
playerActualY = playerCanvasY;
player = new Player(width / 2, playerCanvasY);
lanes = [];
// Generate initial lanes, starting from the player's lane and moving upwards
for (let i = 0; i < NUM_PREGEN_LANES; i++) {
let y = playerCanvasY - LANE_HEIGHT * i;
lanes.push(generateNewLane(y));
}
// Ensure the starting lanes are safe (grass)
for (let i = 0; i < PLAYER_START_LANE_INDEX; i++) {
lanes[i].type = 'grass';
lanes[i].obstacles = [];
lanes[i].speed = 0;
}
scrollOffset = 0;
gameState = 'START';
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
for-loopGenerate Starting Lanesfor (let i = 0; i < NUM_PREGEN_LANES; i++) {
Builds the full stack of lanes above the player before the game begins
for-loopForce Safe Starting Lanesfor (let i = 0; i < PLAYER_START_LANE_INDEX; i++) {
Overwrites the first few lanes near the player to be safe grass, avoiding an instant-death start
Forces the first few lanes near the player to be safe grass with no obstacles, guaranteeing a fair start.
windowResized()
windowResized() is a p5.js lifecycle function you can define to make your sketch responsive to browser window changes.
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
// Re-initialize game elements for responsive layout
// This is a quick fix, ideally parameters like LANE_HEIGHT should be dynamic.
highestScore = getItem('crossyRoadHighestScore') || 0;
resetGame();
// playerCanvasY and player.y are updated in resetGame
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js callback that fires automatically whenever the browser window is resized; this resizes the canvas to match.
resetGame();
Rebuilds the entire game layout from scratch to fit the new canvas size - simple but means any in-progress run is lost on resize.
updateHighestScore()
This function demonstrates simple persistent state in a web sketch - getItem/storeItem wrap the browser's localStorage API so scores survive page reloads.
function updateHighestScore() {
if (score > highestScore) {
highestScore = score;
storeItem('crossyRoadHighestScore', highestScore); // Save highest score
}
}
Line-by-line explanation (2 lines)
🔧 Subcomponents:
conditionalNew High Score Checkif (score > highestScore) {
Only updates and saves when the current score actually beats the previous record
if (score > highestScore) {
Only proceeds if the just-finished run set a new personal best.
p5.js's storeItem() saves a value into the browser's local storage under a string key, so it persists even after the page is closed and reopened.
📦 Key Variables
LANE_HEIGHTnumber
Pixel height of every lane strip; also used as the movement step size for the player.
const LANE_HEIGHT = 60;
PLAYER_SIZEnumber
Width/height of the player's square, derived from LANE_HEIGHT so it always fits nicely within a lane.
const PLAYER_SIZE = LANE_HEIGHT * 0.8;
NUM_PREGEN_LANESnumber
How many lanes are kept generated above the screen at all times.
const NUM_PREGEN_LANES = 15;
PLAYER_START_LANE_INDEXnumber
How many lanes from the bottom the player begins on, used to guarantee a few safe grass lanes at the start.
const PLAYER_START_LANE_INDEX = 2;
OBSTACLE_DENSITYnumber
Probability (0-1) that any given gap in a lane spawns a car or log.
const OBSTACLE_DENSITY = 0.3;
CAR_SPEED_FACTORnumber
Base multiplier for how fast cars move in road lanes.
const CAR_SPEED_FACTOR = 1.5;
LOG_SPEED_FACTORnumber
Base multiplier for how fast logs drift in water lanes.
const LOG_SPEED_FACTOR = 1;
playerobject
The single Player instance representing the character being controlled.
let player;
lanesarray
Holds all currently active Lane objects, ordered roughly by vertical position.
let lanes = [];
scrollOffsetnumber
The vertical pixel offset added when drawing lanes/obstacles to simulate the camera scrolling as the player advances.
let scrollOffset = 0;
playerCanvasYnumber
The player's fixed, never-changing y position on the actual screen.
let playerCanvasY;
playerActualYnumber
The player's true position within the endless game world, used for scoring and determining which lane they're in.
let playerActualY;
scorenumber
Tracks the farthest distance traveled this run; only ever increases via max().
let score = 0;
highestScorenumber
The best score ever achieved, loaded from and saved to local storage.
let highestScore = 0;
gameStatestring
Current screen/mode of the game - 'START', 'PLAYING', or 'GAME_OVER' - controlling what draw() renders and how input is handled.
let gameState = 'START';
🔧 Potential Improvements (4)
Here are some ways this code could be enhanced:
BUGkeyPressed() and touchStarted()
The restart logic sets `gameState = 'PLAYING';` before checking `if (gameState === 'GAME_OVER') { resetGame(); }`, so that condition is always false and resetGame() never actually runs when restarting after a loss.
💡 Save the previous state first, e.g. `let wasGameOver = gameState === 'GAME_OVER'; gameState = 'PLAYING'; if (wasGameOver) resetGame();` so the game properly resets on restart.
PERFORMANCELane.update()
Array.filter() allocates a brand-new array for every lane on every single frame, even when no obstacles have gone off-screen, which adds unnecessary garbage collection pressure with many lanes.
💡 Only filter when at least one obstacle actually reports isOffScreen(), or use a manual splice-in-place loop to avoid reallocating arrays every frame.
STYLELane.generateObstacles() and Lane.update()
The obstacle-width and spawn-position logic is duplicated almost identically between generateObstacles() and update(), making future changes error-prone (you'd need to edit both places consistently).
💡 Extract a shared helper like `createRandomObstacle(x, type, speed)` that both methods call, keeping obstacle-creation rules in one place.
FEATUREplayGame()
Player movement is an instant teleport by LANE_HEIGHT with no animation, which can feel abrupt compared to the classic Crossy Road hop.
💡 Store a target x/y and lerp() the player's drawn position toward it over a few frames to create a smooth hopping animation.
The Crossy Road sketch visually represents a top-down view of a character navigating through various lanes filled with obstacles like cars and logs. The lanes are designed to scroll vertically, giving the impression of movement as the player character attempts to avoid collisions and move upwards.
How can users interact with the Crossy Road sketch?
Users can interact with the Crossy Road sketch by using keyboard controls to move the player character left and right across the lanes. The game also features a scoring system that tracks how far the player progresses vertically in the game.
What creative coding technique does the Crossy Road sketch demonstrate?
The Crossy Road sketch demonstrates the use of object-oriented programming in p5.js through the implementation of classes for the player and obstacles. This technique helps in organizing the code for better management of game entities and their behaviors.
How could someone recreate a similar effect in p5.js?
To recreate a similar effect in p5.js, one can use arrays to manage multiple lanes and obstacles, and implement a scrolling background to simulate movement. Incorporating keyboard controls for player movement and a scoring system to track progress will enhance interactivity.